/**
 * This file is Copyright © 2008 Software Craftsmen Limited. All Rights Reserved.
 */
package com.softwarecraftsmen.orogeny.actions;

import com.softwarecraftsmen.orogeny.buildEnvironments.BuildEnvironment;
import com.softwarecraftsmen.orogeny.buildLogs.BuildLog;
import static com.softwarecraftsmen.orogeny.buildLogs.Verbosity.AllDetails;
import static com.softwarecraftsmen.orogeny.buildLogs.Verbosity.Summary;
import com.softwarecraftsmen.orogeny.actions.execute.ConvenientThread;
import com.softwarecraftsmen.orogeny.actions.execute.Stop;
import com.softwarecraftsmen.orogeny.actions.execute.StoppableRunnable;
import com.softwarecraftsmen.orogeny.execution.SuccessOrFailure;
import static com.softwarecraftsmen.orogeny.execution.SuccessOrFailure.Failure;
import static com.softwarecraftsmen.orogeny.execution.SuccessOrFailure.Success;
import com.softwarecraftsmen.orogeny.filing.AbsoluteDirectory;
import com.softwarecraftsmen.orogeny.filing.Execute;
import com.softwarecraftsmen.orogeny.properties.Evaluatable;
import com.softwarecraftsmen.orogeny.properties.FixedValueProperty;
import com.softwarecraftsmen.orogeny.properties.PropertyName;
import org.jetbrains.annotations.NotNull;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import static java.lang.String.format;
import static java.lang.System.currentTimeMillis;
import static java.lang.System.getenv;
import static java.lang.Thread.*;
import java.util.ArrayList;
import static java.util.Arrays.asList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import static java.util.Locale.UK;
import java.util.Map;
import java.util.concurrent.Callable;

public class ExecuteAction extends AbstractAction
{
	private final Execute executable;
	private final AbsoluteDirectory workingDirectory;
	private final long timeOutInMilliseconds;
	private final List<String> arguments;
	private final Map<String, Evaluatable> environmentVariables;
	private static final int TwoMilliseconds = 2;

	public static final int OneSecond = 1000;
	public static final int FiveSeconds = 5 * OneSecond;
	public static final int TenSeconds = 10 * OneSecond;
	public static final int FifteenSeconds = 15 * OneSecond;
	public static final int TwentySeconds = 20 * OneSecond;
	public static final int TwentyFiveSeconds = 25 * OneSecond;
	public static final int ThirtySeconds = 30 * OneSecond;
	public static final int OneMinute = 60 * OneSecond;
	public static final int TwoMinutes = 2 * OneMinute;
	public static final int ThreeMinutes = 3 * OneMinute;
	public static final int FourMinutes = 4 * OneMinute;
	public static final int FiveMinutes = 5 * OneMinute;
	public static final int TenMinutes = 10 * OneMinute;
	public static final int FiftenMinutes = 15 * OneMinute;
	public static final int TwentyMinutes = 20 * OneMinute;
	public static final int TwentyFiveMinutes = 25 * OneMinute;
	public static final int ThirtyMinutes = 30 * OneMinute;
	public static final int OneHour = 60 * OneMinute;
	public static final int TwoHours = 2 * OneHour;
	public static final int ThreeHours = 3 * OneHour;
	public static final int FourHours = 4 * OneHour;
	public static final int FiveHours = 5 * OneHour;
	private static final int OneKilobyte = 1024;

	//TODO:"cmd /c dir", "ls", etc [use /usr/bin/env ls on Unix]
	//Create a variant that resolves using a 'command interpreter'
	public ExecuteAction(final @NotNull Execute executable, final @NotNull AbsoluteDirectory workingDirectory, final long timeOutInMilliseconds, final @NotNull List<String> arguments, final @NotNull Map<String, Evaluatable> environmentVariables)
	{
		this.executable = executable;
		this.workingDirectory = workingDirectory;
		this.timeOutInMilliseconds = timeOutInMilliseconds;
		this.arguments = arguments;
		this.environmentVariables = environmentVariables;
	}

	@NotNull
	public String describeWhatWeDo()
	{
		return "execute";
	}

	@NotNull
	public SuccessOrFailure execute(final @NotNull BuildLog buildLog, final @NotNull BuildEnvironment buildEnvironment)
	{
		final Map<String, String> parsedEnvironmentVariables = new LinkedHashMap<String, String>()
		{{
			for (String environmentVariableName : environmentVariables.keySet())
			{
				put(environmentVariableName, environmentVariables.get(environmentVariableName).evaluate(buildEnvironment));
			}
		}};

		buildLog.writeMessage(Summary, format(UK, "About to execute %1$s with working directory %2$s with arguments %3$s and environment variables %4$s", executable, workingDirectory, arguments, parsedEnvironmentVariables));

		if (!executable.isExecutable())
		{
			return Failure(format(UK, "Executable %1$s does not exist or is not executable", executable));
		}

		if (!workingDirectory.exists())
		{
			return Failure(format(UK, "Working Directory %1$s does not exist", workingDirectory));
		}

		final Process process;
		final long endTimeInMilliseconds = currentTimeMillis() + timeOutInMilliseconds;
		try
		{
			process = createProcess(parsedEnvironmentVariables).start();
			endInput(process);
		}
		catch (IOException e)
		{
			return Failure(e);
		}

		final Stop stop = new Stop();
		readOutput("stdout", process.getInputStream(), buildLog, stop);
		readOutput("stderr", process.getErrorStream(), buildLog, stop);
		boolean timedOut;
		boolean exited;
		do
		{
			waitALittleWhile();
			timedOut = currentTimeMillis() > endTimeInMilliseconds;
			exited = hasExited(process);
		} while (!(exited || timedOut));

		if (timedOut)
		{
			process.destroy();
		}

		stop.stop();

		if (timedOut)
		{
			final String message = format(UK, "Timed out after %1$s milliseconds", timedOut);
			buildLog.writeMessage(Summary, message);
			return Failure(message);
		}
		else
		{
			final int exitCode = process.exitValue();
			buildLog.writeMessage(Summary, format(UK, "Exit code was %1$s", exitCode));

			if (exitCode == 0)
			{
				return Success;
			}
			else
			{
				return Failure(format(UK, "Executable %1$s failed with exit code %2$s", executable, exitCode));
			}
		}
	}

	private static void readOutput(final @NotNull String prefix, final @NotNull InputStream inputStream, final @NotNull BuildLog buildLog, final @NotNull Stop stop)
	{
		new ConvenientThread(format(UK, "Process Stream Reader %1$s", prefix), MAX_PRIORITY, true, new StoppableRunnable(stop, new Callable<Boolean>()
		{
			final int bufferSize = 4 * OneKilobyte;
			private final BufferedReader reader = new BufferedReader(new InputStreamReader(new BufferedInputStream(inputStream, bufferSize)), bufferSize);

			@NotNull
			public Boolean call() throws Exception
			{
				final String line;
				try
				{
					line = reader.readLine();
				}
				catch (IOException e)
				{
					throw new IllegalStateException(e);
				}
				final boolean readALine = line != null;
				if (readALine)
				{
					synchronized(buildLog)
					{
						buildLog.writeMessage(AllDetails, format(UK, "%1$s:%2$s", prefix, line));
					}
				}
				return readALine;
			}
		})).start();
	}

	private void endInput(final Process process) throws IOException {process.getOutputStream().close();}

	@NotNull
	private ProcessBuilder createProcess(final @NotNull Map<String, String> parsedEnvironmentVariables)
	{
		final ProcessBuilder processBuilder = new ProcessBuilder();
		workingDirectory.setDirectory(processBuilder);
		executable.setExecutable(processBuilder);
		processBuilder.command().addAll(arguments);
		processBuilder.environment().clear();
		processBuilder.environment().putAll(parsedEnvironmentVariables);
		return processBuilder;
	}

	@SuppressWarnings({"EmptyCatchBlock"})
	private static void waitALittleWhile()
	{
		try
		{
			sleep(TwoMilliseconds);
		}
		catch (InterruptedException e)
		{}
		yield();
	}

	private boolean hasExited(final @NotNull Process process)
	{
		try
		{
			process.exitValue();
			return true;
		}
		catch (IllegalThreadStateException hasNotYetExited)
		{
			return false;
		}
	}

	@NotNull
	public static SpecifyExecutable execute(final @NotNull Execute executable)
	{
		return new SpecifyExecutable(executable);
	}

	public static final class SpecifyExecutable
	{
		private final Execute executable;
		private static final List<String> NoArguments = Collections.emptyList();
		private static final Map<String, Evaluatable> NoEnvironmentVariables = Collections.emptyMap();

		private SpecifyExecutable(final Execute executable) {this.executable = executable;}

		@NotNull
		public SpecifyWorkingDirectory inWorkingDirectory(final @NotNull AbsoluteDirectory workingDirectory)
		{
			return new SpecifyWorkingDirectory(workingDirectory);
		}

		public final class SpecifyWorkingDirectory
		{
			private final AbsoluteDirectory workingDirectory;

			private SpecifyWorkingDirectory(final @NotNull AbsoluteDirectory workingDirectory) {this.workingDirectory = workingDirectory;}

			@NotNull
			public SpecifyTimeOutInMilliseconds forUpTo(final int timeOutInMilliseconds)
			{
				return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, NoArguments, NoEnvironmentVariables);
			}

			public final class SpecifyTimeOutInMilliseconds extends AbstractAction
			{
				private final int timeOutInMilliseconds;
				private final List<String> arguments;
				private final Map<String, Evaluatable> environmentVariables;

				private SpecifyTimeOutInMilliseconds(final int timeOutInMilliseconds, final @NotNull List<String> arguments, final @NotNull Map<String, Evaluatable> environmentVariables)
				{
					this.timeOutInMilliseconds = timeOutInMilliseconds;
					this.arguments = arguments;
					this.environmentVariables = environmentVariables;
				}

				@NotNull
				public String describeWhatWeDo()
				{
					return new ExecuteAction(executable, workingDirectory, timeOutInMilliseconds, arguments, environmentVariables).describeWhatWeDo();
				}

				@NotNull
				public SuccessOrFailure execute(final @NotNull BuildLog buildLog, final @NotNull BuildEnvironment buildEnvironment)
				{
					return new ExecuteAction(executable, workingDirectory, timeOutInMilliseconds, arguments, environmentVariables).execute(buildLog, buildEnvironment);
				}

				@NotNull
				public SpecifyTimeOutInMilliseconds withArgument(final @NotNull String argument)
				{
					return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, new ArrayList<String>(arguments) {{ add(argument); }}, environmentVariables);
				}

				@NotNull
				public SpecifyTimeOutInMilliseconds withArguments(final @NotNull String... arguments)
				{
					return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, new ArrayList<String>(this.arguments) {{ addAll(asList(arguments)); }}, environmentVariables);
				}

				@NotNull
				public SpecifyTimeOutInMilliseconds withInheritedEnvironmentVariables()
				{
					return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, arguments, new LinkedHashMap<String, Evaluatable>(environmentVariables)
					{{
						for (Map.Entry<String, String> pair : getenv().entrySet())
						{
							put(pair.getKey(), new FixedValueProperty(pair.getValue()));
						}
					}});
				}

				@NotNull
				public SpecifyTimeOutInMilliseconds excludingEnvironmentVariables(final @NotNull String... names)
				{
					return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, arguments, new LinkedHashMap<String, Evaluatable>(environmentVariables)
					{{
						for (String name : names)
						{
							remove(name);
						}
					}});
				}

				@NotNull
				public SpecifyTimeOutInMilliseconds withEnvironmentVariable(final @NotNull String name, final @NotNull String value)
				{
					return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, arguments, new LinkedHashMap<String, Evaluatable>(environmentVariables){{ put(name, new FixedValueProperty(value)); }});
				}

				@NotNull
				public SpecifyTimeOutInMilliseconds withEnvironmentVariable(final @NotNull String name, final @NotNull PropertyName value)
				{
					return new SpecifyTimeOutInMilliseconds(timeOutInMilliseconds, arguments, new LinkedHashMap<String, Evaluatable>(environmentVariables)
					{
						{ put(name, value); }
					});
				}
			}
		}
	}
}
